/*
 * Copyright (c) 2014-present, Facebook, Inc.
 * All rights reserved.
 *
 * This source code is licensed under the BSD-style license found in the
 * LICENSE file in the root directory of this source tree. An additional grant
 * of patent rights can be found in the PATENTS file in the same directory.
 */

package com.facebook.stetho.inspector.protocol.module;

import android.content.Context;
import com.facebook.stetho.Stetho;
import com.facebook.stetho.inspector.console.RuntimeRepl;
import com.facebook.stetho.inspector.console.RuntimeReplFactory;
import com.facebook.stetho.inspector.helper.ObjectIdMapper;
import com.facebook.stetho.inspector.jsonrpc.DisconnectReceiver;
import com.facebook.stetho.inspector.jsonrpc.JsonRpcException;
import com.facebook.stetho.inspector.jsonrpc.JsonRpcPeer;
import com.facebook.stetho.inspector.jsonrpc.JsonRpcResult;
import com.facebook.stetho.inspector.jsonrpc.protocol.JsonRpcError;
import com.facebook.stetho.inspector.protocol.ChromeDevtoolsDomain;
import com.facebook.stetho.inspector.runtime.RhinoDetectingRuntimeReplFactory;
import com.facebook.stetho.json.ObjectMapper;
import com.facebook.stetho.json.annotation.JsonProperty;
import com.facebook.stetho.json.annotation.JsonValue;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import org.json.JSONObject;

public class Runtime implements ChromeDevtoolsDomain {
    private static final Map<JsonRpcPeer, Session> sSessions =
            Collections.synchronizedMap(new HashMap<JsonRpcPeer, Session>());
    private final ObjectMapper mObjectMapper = new ObjectMapper();
    private final RuntimeReplFactory mReplFactory;

    /**
     * @see #Runtime(RuntimeReplFactory)
     * @see Stetho.DefaultInspectorModulesBuilder#runtimeRepl(RuntimeReplFactory)
     * @deprecated Provided for ABI compatibility
     */
    @Deprecated
    public Runtime() {
        this(
                new RuntimeReplFactory() {
                    @Override
                    public RuntimeRepl newInstance() {
                        return new RuntimeRepl() {
                            @Override
                            public Object evaluate(String expression) throws Throwable {
                                return "Not supported with legacy Runtime module";
                            }
                        };
                    }
                });
    }

    /**
     * @deprecated This was a transitionary API that was replaced by {@link
     * com.facebook.stetho.Stetho.DefaultInspectorModulesBuilder#runtimeRepl}
     */
    public Runtime(Context context) {
        this(new RhinoDetectingRuntimeReplFactory(context));
    }

    public Runtime(RuntimeReplFactory replFactory) {
        mReplFactory = replFactory;
    }

    public static int mapObject(JsonRpcPeer peer, Object object) {
        return getSession(peer).getObjects().putObject(object);
    }

    @Nonnull
    private static synchronized Session getSession(final JsonRpcPeer peer) {
        Session session = sSessions.get(peer);
        if (session == null) {
            session = new Session();
            sSessions.put(peer, session);
            peer.registerDisconnectReceiver(
                    new DisconnectReceiver() {
                        @Override
                        public void onDisconnect() {
                            sSessions.remove(peer);
                        }
                    });
        }
        return session;
    }

    // INSPECTOR DEL BEGIN
    // @ChromeDevtoolsMethod
    // public void releaseObject(JsonRpcPeer peer, JSONObject params) throws JSONException {
    //  String objectId = params.getString("objectId");
    //  getSession(peer).getObjects().removeObjectById(Integer.parseInt(objectId));
    // }

    // @ChromeDevtoolsMethod
    // public void releaseObjectGroup(JsonRpcPeer peer, JSONObject params) {
    //  LogUtil.w("Ignoring request to releaseObjectGroup: " + params);
    // }

    // @ChromeDevtoolsMethod
    // public CallFunctionOnResponse callFunctionOn(JsonRpcPeer peer, JSONObject params)
    //    throws JsonRpcException {
    //  CallFunctionOnRequest args = mObjectMapper.convertValue(params, CallFunctionOnRequest.class);

    //  Session session = getSession(peer);
    //  Object object = session.getObjectOrThrow(args.objectId);

    //  // The DevTools UI thinks it can run arbitrary JavaScript against us in order to figure out
    //  // the class structure of an object.  That obviously won't fly, and there's no way to
    //  // translate without building a crude JavaScript parser so let's just go ahead and guess
    //  // what this function does by name.
    //  if (!args.functionDeclaration.startsWith("function protoList(")) {
    //    throw new JsonRpcException(
    //        new JsonRpcError(
    //            JsonRpcError.ErrorCode.INTERNAL_ERROR,
    //            "Expected protoList, got: " + args.functionDeclaration,
    //            null /* data */));
    //  }

    //  // Since this is really a function call we have to create this fake object to hold the
    //  // "result" of the function.
    //  ObjectProtoContainer objectContainer = new ObjectProtoContainer(object);
    //  RemoteObject result = new RemoteObject();
    //  result.type = ObjectType.OBJECT;
    //  result.subtype = ObjectSubType.NODE;
    //  result.className = object.getClass().getName();
    //  result.description = getPropertyClassName(object);
    //  result.objectId = String.valueOf(session.getObjects().putObject(objectContainer));

    //  CallFunctionOnResponse response = new CallFunctionOnResponse();
    //  response.result = result;
    //  response.wasThrown = false;

    //  return response;
    // }

    // @ChromeDevtoolsMethod
    // public JsonRpcResult evaluate(JsonRpcPeer peer, JSONObject params) {
    //  return getSession(peer).evaluate(mReplFactory, params);
    // }

    // @ChromeDevtoolsMethod
    // public JsonRpcResult getProperties(JsonRpcPeer peer, JSONObject params) throws JsonRpcException
    // {
    //  return getSession(peer).getProperties(params);
    // }
    // END

    private static String getPropertyClassName(Object o) {
        String name = o.getClass().getSimpleName();
        if (name == null || name.length() == 0) {
            // Looks better for anonymous classes.
            name = o.getClass().getName();
        }
        return name;
    }

    public static enum ObjectType {
        OBJECT("object"),
        FUNCTION("function"),
        UNDEFINED("undefined"),
        STRING("string"),
        NUMBER("number"),
        BOOLEAN("boolean"),
        SYMBOL("symbol");

        private final String mProtocolValue;

        private ObjectType(String protocolValue) {
            mProtocolValue = protocolValue;
        }

        @JsonValue
        public String getProtocolValue() {
            return mProtocolValue;
        }
    }

    public static enum ObjectSubType {
        ARRAY("array"),
        NULL("null"),
        NODE("node"),
        REGEXP("regexp"),
        DATE("date"),
        MAP("map"),
        SET("set"),
        ITERATOR("iterator"),
        GENERATOR("generator"),
        ERROR("error");

        private final String mProtocolValue;

        private ObjectSubType(String protocolValue) {
            mProtocolValue = protocolValue;
        }

        @JsonValue
        public String getProtocolValue() {
            return mProtocolValue;
        }
    }

    private static class ObjectProtoContainer {
        public final Object object;

        public ObjectProtoContainer(Object object) {
            this.object = object;
        }
    }

    /**
     * Object representing a session with a single client.
     *
     * <p>Clients inherently leak object references because they can expand any object in the UI at
     * any time. Grouping references by client allows us to drop them when the client disconnects.
     */
    private static class Session {
        private final ObjectIdMapper mObjects = new ObjectIdMapper();
        private final ObjectMapper mObjectMapper = new ObjectMapper();

        @Nullable
        private RuntimeRepl mRepl;

        public ObjectIdMapper getObjects() {
            return mObjects;
        }

        public Object getObjectOrThrow(String objectId) throws JsonRpcException {
            Object object = getObjects().getObjectForId(Integer.parseInt(objectId));
            if (object == null) {
                throw new JsonRpcException(
                        new JsonRpcError(
                                JsonRpcError.ErrorCode.INVALID_REQUEST,
                                "No object found for " + objectId,
                                null /* data */));
            }
            return object;
        }

        public RemoteObject objectForRemote(Object value) {
            RemoteObject result = new RemoteObject();
            if (value == null) {
                result.type = ObjectType.OBJECT;
                result.subtype = ObjectSubType.NULL;
                result.value = JSONObject.NULL;
            } else if (value instanceof Boolean) {
                result.type = ObjectType.BOOLEAN;
                result.value = value;
            } else if (value instanceof Number) {
                result.type = ObjectType.NUMBER;
                result.value = value;
            } else if (value instanceof Character) {
                // Unclear whether we should expose these as strings, numbers, or something else.
                result.type = ObjectType.NUMBER;
                result.value = Integer.valueOf(((Character) value).charValue());
            } else if (value instanceof String) {
                result.type = ObjectType.STRING;
                result.value = String.valueOf(value);
            } else {
                result.type = ObjectType.OBJECT;
                result.className = "What??"; // I have no idea where this is used.
                result.objectId = String.valueOf(mObjects.putObject(value));

                if (value.getClass().isArray()) {
                    result.description = "array";
                } else if (value instanceof List) {
                    result.description = "List";
                } else if (value instanceof Set) {
                    result.description = "Set";
                } else if (value instanceof Map) {
                    result.description = "Map";
                } else {
                    result.description = getPropertyClassName(value);
                }
            }
            return result;
        }

        public EvaluateResponse evaluate(RuntimeReplFactory replFactory, JSONObject params) {
            EvaluateRequest request = mObjectMapper.convertValue(params, EvaluateRequest.class);
            // INSPECTOR ADD
            if (null == request) {
                return buildExceptionResponse("evaluate: request is null");
            }
            // END
            try {
                // INSPECTOR MOD:
                // if (!request.objectGroup.equals("console")) {
                if (request != null && !request.objectGroup.equals("console")) {
                    // END
                    return buildExceptionResponse("Not supported by FAB");
                }

                RuntimeRepl repl = getRepl(replFactory);
                Object result = repl.evaluate(request.expression);
                return buildNormalResponse(result);
            } catch (Throwable t) {
                return buildExceptionResponse(t);
            }
        }

        @Nonnull
        private synchronized RuntimeRepl getRepl(RuntimeReplFactory replFactory) {
            if (mRepl == null) {
                mRepl = replFactory.newInstance();
            }
            return mRepl;
        }

        private EvaluateResponse buildNormalResponse(Object retval) {
            EvaluateResponse response = new EvaluateResponse();
            response.wasThrown = false;
            response.result = objectForRemote(retval);
            return response;
        }

        private EvaluateResponse buildExceptionResponse(Object retval) {
            EvaluateResponse response = new EvaluateResponse();
            response.wasThrown = true;
            response.result = objectForRemote(retval);
            response.exceptionDetails = new ExceptionDetails();
            response.exceptionDetails.text = retval.toString();
            return response;
        }

        public GetPropertiesResponse getProperties(JSONObject params) throws JsonRpcException {
            GetPropertiesRequest request =
                    mObjectMapper.convertValue(params, GetPropertiesRequest.class);
            // INSPECTOR MOD:
            if (null == request) {
                GetPropertiesResponse response = new GetPropertiesResponse();
                response.result = new ArrayList<>();
                return response;
            }
            // END
            // INSPECTOR MOD:
            // if (!request.ownProperties) {
            if (request != null && !request.ownProperties) {
                // END
                GetPropertiesResponse response = new GetPropertiesResponse();
                response.result = new ArrayList<>();
                return response;
            }

            Object object = getObjectOrThrow(request.objectId);

            if (object.getClass().isArray()) {
                object = arrayToList(object);
            }

            if (object instanceof ObjectProtoContainer) {
                return getPropertiesForProtoContainer((ObjectProtoContainer) object);
            } else if (object instanceof List) {
                return getPropertiesForIterable((List) object, /* enumerate */ true);
            } else if (object instanceof Set) {
                return getPropertiesForIterable((Set) object, /* enumerate */ false);
            } else if (object instanceof Map) {
                return getPropertiesForMap(object);
            } else {
                return getPropertiesForObject(object);
            }
        }

        private List<?> arrayToList(Object object) {
            Class<?> type = object.getClass();
            if (!type.isArray()) {
                throw new IllegalArgumentException("Argument must be an array.  Was " + type);
            }
            Class<?> component = type.getComponentType();
            // INSPECTOR MOD
            if (component != null && !component.isPrimitive()) {
                // END
                return Arrays.asList((Object[]) object);
            }

            // Loop manually for primitives.
            int length = Array.getLength(object);
            List<Object> ret = new ArrayList<>(length);
            for (int i = 0; i < length; i++) {
                ret.add(Array.get(object, i));
            }
            return ret;
        }

        // Normally JavaScript will return the full class hierarchy as a list.  That seems less
        // useful for Java since it's more natural (IMO) to see all available member variables in one
        // big list.
        private GetPropertiesResponse getPropertiesForProtoContainer(ObjectProtoContainer proto) {
            Object target = proto.object;
            RemoteObject protoRemote = new RemoteObject();
            protoRemote.type = ObjectType.OBJECT;
            protoRemote.subtype = ObjectSubType.NODE;
            protoRemote.className = target.getClass().getName();
            protoRemote.description = getPropertyClassName(target);
            protoRemote.objectId = String.valueOf(mObjects.putObject(target));
            PropertyDescriptor descriptor = new PropertyDescriptor();
            descriptor.name = "1";
            descriptor.value = protoRemote;
            GetPropertiesResponse response = new GetPropertiesResponse();
            response.result = new ArrayList<>(1);
            response.result.add(descriptor);
            return response;
        }

        private GetPropertiesResponse getPropertiesForIterable(Iterable<?> object,
                                                               boolean enumerate) {
            GetPropertiesResponse response = new GetPropertiesResponse();
            List<PropertyDescriptor> properties = new ArrayList<>();

            int index = 0;
            for (Object value : object) {
                PropertyDescriptor property = new PropertyDescriptor();
                property.name = enumerate ? String.valueOf(index++) : null;
                property.value = objectForRemote(value);
                properties.add(property);
            }

            response.result = properties;
            return response;
        }

        private GetPropertiesResponse getPropertiesForMap(Object object) {
            GetPropertiesResponse response = new GetPropertiesResponse();
            List<PropertyDescriptor> properties = new ArrayList<>();

            for (Map.Entry<?, ?> entry : ((Map<?, ?>) object).entrySet()) {
                PropertyDescriptor property = new PropertyDescriptor();
                property.name = String.valueOf(entry.getKey());
                property.value = objectForRemote(entry.getValue());
                properties.add(property);
            }

            response.result = properties;
            return response;
        }

        private GetPropertiesResponse getPropertiesForObject(Object object) {
            GetPropertiesResponse response = new GetPropertiesResponse();
            List<PropertyDescriptor> properties = new ArrayList<>();
            for (Class<?> declaringClass = object.getClass();
                    declaringClass != null;
                    declaringClass = declaringClass.getSuperclass()) {
                // Reverse the list of fields while going up the superclass chain.
                // When we're done, we'll reverse the full list so that the superclasses
                // appear at the top, but within each class they properties are in declared order.
                List<Field> fields =
                        new ArrayList<Field>(Arrays.asList(declaringClass.getDeclaredFields()));
                Collections.reverse(fields);
                String prefix =
                        declaringClass == object.getClass() ? "" :
                                declaringClass.getSimpleName() + ".";
                for (Field field : fields) {
                    if (Modifier.isStatic(field.getModifiers())) {
                        continue;
                    }
                    field.setAccessible(true);
                    try {
                        Object fieldValue = field.get(object);
                        PropertyDescriptor property = new PropertyDescriptor();
                        property.name = prefix + field.getName();
                        property.value = objectForRemote(fieldValue);
                        properties.add(property);
                    } catch (IllegalAccessException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
            Collections.reverse(properties);
            response.result = properties;
            return response;
        }
    }

    private static class CallFunctionOnRequest {
        @JsonProperty
        public String objectId;

        @JsonProperty
        public String functionDeclaration;

        @JsonProperty
        public List<CallArgument> arguments;

        @JsonProperty(required = false)
        public Boolean doNotPauseOnExceptionsAndMuteConsole;

        @JsonProperty(required = false)
        public Boolean returnByValue;

        @JsonProperty(required = false)
        public Boolean generatePreview;
    }

    private static class CallFunctionOnResponse implements JsonRpcResult {
        @JsonProperty
        public RemoteObject result;

        @JsonProperty(required = false)
        public Boolean wasThrown;
    }

    private static class CallArgument {
        @JsonProperty(required = false)
        public Object value;

        @JsonProperty(required = false)
        public String objectId;

        @JsonProperty(required = false)
        public ObjectType type;
    }

    private static class GetPropertiesRequest implements JsonRpcResult {
        @JsonProperty(required = true)
        public boolean ownProperties;

        @JsonProperty(required = true)
        public String objectId;
    }

    private static class GetPropertiesResponse implements JsonRpcResult {
        @JsonProperty(required = true)
        public List<PropertyDescriptor> result;
    }

    private static class EvaluateRequest implements JsonRpcResult {
        @JsonProperty(required = true)
        public String objectGroup;

        @JsonProperty(required = true)
        public String expression;
    }

    private static class EvaluateResponse implements JsonRpcResult {
        @JsonProperty(required = true)
        public RemoteObject result;

        @JsonProperty(required = true)
        public boolean wasThrown;

        @JsonProperty
        public ExceptionDetails exceptionDetails;
    }

    private static class ExceptionDetails {
        @JsonProperty(required = true)
        public String text;
    }

    public static class RemoteObject {
        @JsonProperty(required = true)
        public ObjectType type;

        @JsonProperty
        public ObjectSubType subtype;

        @JsonProperty
        public Object value;

        @JsonProperty
        public String className;

        @JsonProperty
        public String description;

        @JsonProperty
        public String objectId;
    }

    private static class PropertyDescriptor {
        @JsonProperty(required = true)
        public final boolean isOwn = true;
        @JsonProperty(required = true)
        public final boolean configurable = false;
        @JsonProperty(required = true)
        public final boolean enumerable = true;
        @JsonProperty(required = true)
        public final boolean writable = false;
        @JsonProperty(required = true)
        public String name;
        @JsonProperty(required = true)
        public RemoteObject value;
    }
}
